// Minimalistic cluster-based Node.js server
// Copyright (C) 2011-2012 Jani Kajala. Licensed under MIT-license.
// Dependencies: Node.js (only core modules)

var cluster 		= require('cluster');
var fs 				= require('fs');
var path			= require('path');
var os 				= require('os');
var querystring 	= require('querystring'); // for parse()
var url 			= require('url'); // for parse()
var util			= require('util'); // for debug / inspect

//-------------------------------------------------------------------------
// Helpers (not visible outside this module)
//-------------------------------------------------------------------------

/** Returns UTC datetime in ISO format. Example: 2011-10-12T23:13:21Z */
function now() {
	return (new Date()).toISOString();
}

/** Returns one level deep copy of object */
function clone( obj ) {
	var c = {};
	if ( typeof obj != 'undefined' ) {
		for ( var k in obj )
			c[k] = obj[k];
	}
	return c;
}

/** Returns one level deep merge of two objects */
function merge( a, b ) {
	var c = clone(a);
	if ( typeof b != 'undefined' ) {
		for ( var k in b )
			c[k] = b[k];
	}
	return c;
}

/** Calls sequentially array of functions passing (req,res,next) to each */ 
function routeCallSeq( obj, fns, i,n, req,res ) {
	var fn = fns[i];
	var i1 = i+1;
	fn.apply( obj, [req,res,function() {
		if (i1 < n)
			routeCallSeq( obj, fns, i1,n, req,res );
	}] );
}

/** Reads file and returns the contents if the string is file name. If it's not a file just returns the string as is. */
function readFileOpt( file ) {
	return file && path.existsSync(file) ? fs.readFileSync(file) : file;
}

//-------------------------------------------------------------------------
// Server initialization
//-------------------------------------------------------------------------

/**
 * Constructs the server object but does not start the server.
 * See JServer.defaultConfig for default configuration. All config parameters are optional.
 * See start(), listenHTTP() and listenHTTPS() for starting the server. 
 */
function JServer( config ) {
	this.config = merge( this.defaultConfig, config );
	this.ServerRequest = clone( JServer.prototype.ServerRequest );
	this.ServerResponse = clone( JServer.prototype.ServerResponse );
	this.methods = {GET:{},POST:{},PUT:{},DELETE:{}};
}

/**
 * Default configuration. Can be overriden in JServer constructor on parameter-by-parameter basis.
 * Final configuration object is available in server's config member variable.
 */
JServer.prototype.defaultConfig = {
	/** Number of worker processes. Set to 0 to use number of CPUs as worker count. */
	workers: 0,
	/** More log output */
	verbose: true
};

/** 
 * Starts the master server.
 * @return true if this is the master process, false otherwise.
 */
JServer.prototype.start = function() {
	if (cluster.isMaster) {
		var worker_count = (this.config.workers > 0 ? this.config.workers : os.cpus().length);

		console.log( now()+" Starting master server (process id="+process.pid+") with "+worker_count+" workers. Configuration:" );
		for ( var k in this.config ) {
			if ( k != 'workers' )
				console.log( "    "+k+" = "+this.config[k] );
		}
		
		for ( var i = 0 ; i < worker_count ; i++ ) {
			cluster.fork();
		}

		cluster.on( 'death', function(worker) {
			console.error( now()+' Worker id='+worker.pid+' died, re-launching' );
			cluster.fork();
		} );
	} 
	else {
		console.log( now()+" Starting worker (process id="+process.pid+")" );
	}
	return cluster.isMaster;
};

/**
 * Creates HTTP server and starts listening specified port and host.
 * Attached requests listener which dispatches requests to server.dispatch(req,res).
 * @param port Port to listen to, or false/undefined to use default. Default is 80.
 * @param host Host to listen to, or false/undefined to use default. Default is '0.0.0.0' (any).
 */
JServer.prototype.listenHTTP = function( port, host ) {
	var self = this;
	
	host = (typeof host == 'undefined' || !host ? '0.0.0.0' : host);
	port = (typeof port == 'undefined' || !port ? 80 : port);

	var inst = require('http').createServer();
	
	inst.on( 'request', function(req, res) {
		self.dispatch( req, res );
	} );
	
	console.log( now()+" Process id="+process.pid+" listening http://"+host+":"+port );
	inst.listen( port, host );
};

/**
 * Creates HTTPS server using specified key/cert/ca-bundle and starts listening specified port and host.
 * Attached requests listener which dispatches requests to server.dispatch(req,res).
 * @param port Port to listen to, or false/undefined to use default. Default is 443.
 * @param host Host to listen to, or false/undefined to use default. Default is '0.0.0.0' (any).
 * @param opt SSL options: key, cert, and optional ca. All must be in PEM format (or PEM format files). For more options see tls.createServer in Nodejs docs.
 */
JServer.prototype.listenHTTPS = function( port, host, opt ) {
	var self = this;
	
	opt.key = readFileOpt(opt.key);
	opt.cert = readFileOpt(opt.cert);
	opt.ca = readFileOpt(opt.ca);
	
	host = (typeof host == 'undefined' || !host ? '0.0.0.0' : host);
	port = (typeof port == 'undefined' || !port ? 443 : port);
	
	var inst = require('https').createServer( opt ); 
	
	inst.on( 'request', function(req, res) {
		self.dispatch( req, res );
	} );

	console.log( now()+" Process id="+process.pid+" listening https://"+host+":"+port );
	inst.listen( port, host );
};

//-------------------------------------------------------------------------
// Default router
//-------------------------------------------------------------------------

/**
 * Maps HTTP method request path to specified function.
 * Note that the methods[METHOD][path] map is only used if default route() is used.
 * @param method 'POST'/'GET'/'PUT'/'DELETE'
 * @param args Array consisting of: Path part of URL (or array of paths) followed by functions to call when request path matches
 */
JServer.prototype.mapRoute = function( method, args ) {
	if ( this.config.verbose )
		console.log( "JServer.mapRoute: "+method+" "+util.inspect(args) );
	if ( args.length < 2 )
		throw new Error( "JServer.mapRoute(1): Expects method plus array which consists of path (or path array) and callback function(s) when route matches. Parameters: "+method+", "+util.inspect(args) );
	
	// first param in args is path or array of paths
	var path = args[0]; 
	path = (typeof path == 'string' ? [path] : path);

	// followed by callback function(s)
	var fns = Array.prototype.slice.call(args,1);
	for ( var i = 0 ; i < fns.length ; ++i ) {
		if ( typeof fns[i] != 'function' )
			throw new Error( "JServer.mapRoute(2): Expects method plus array which consists of path (or path array) and callback function(s) when route matches. Parameters: "+method+", "+util.inspect(args) );
	}
	
	for ( var i = 0 ; i < path.length ; ++i ) {
		if ( typeof path[i] != 'string' )
			throw new Error( "JServer.mapRoute(3): Expects method plus array which consists of path (or path array) and callback function(s) when route matches. Parameters: "+method+", "+util.inspect(args) );
		this.methods[method][ path[i] ] = fns;
	}
};

/**
 * Maps HTTP GET request path to specified function.
 * Note that the methods[METHOD][path] map is only used if default route() is used.
 * @param path Array of paths or single path string
 * @param fn1 First call-back function when request path matches. Followed by other callbacks.
 * @param fn2 Second call-back function when request path matches. Followed by other callbacks.
 * @param ...
 */
JServer.prototype.get = function() {
	this.mapRoute( 'GET', arguments );
};

/**
 * Maps HTTP POST request path to specified function.
 * Note that this mapping is only used if default route() is used.
 * @param path Array of paths or single path string
 * @param fn1 First call-back function when request path matches. Followed by other callbacks.
 * @param fn2 Second call-back function when request path matches. Followed by other callbacks.
 * @param ...
 */
JServer.prototype.post = function() {
	this.mapRoute( 'POST', arguments );
};
	
/**
 * Maps HTTP PUT request path to specified function.
 * Note that this mapping is only used if default route() is used.
 * @param path Array of paths or single path string
 * @param fn1 First call-back function when request path matches. Followed by other callbacks.
 * @param fn2 Second call-back function when request path matches. Followed by other callbacks.
 * @param ...
 */
JServer.prototype.put = function() {
	this.mapRoute( 'PUT', arguments );
};
	
/**
 * Maps HTTP DELETE request path to specified function.
 * Note that this mapping is only used if default route() is used.
 * @param path Array of paths or single path string
 * @param fn1 First call-back function when request path matches. Followed by other callbacks.
 * @param fn2 Second call-back function when request path matches. Followed by other callbacks.
 * @param ...
 */
JServer.prototype.del = function() {
	this.mapRoute( 'DELETE', arguments );
};

/** 
 * Route fall-back if URL pathname does not match any handler.
 */
JServer.prototype.notFound = function( req, res, err ) {
	if (!err) err = now()+" HTTP "+req.method+" "+req.url+" not found, headers="+util.inspect(req.headers);
	console.error( err );
	res.statusCode = 404;
	res.end( err );
};

/**
 * Routes a request to associated handler using specified pathname.
 * Calls methods functions associated to request pathname with get()/post()/put()/del(),
 * or notFound() in case no function matching pathname not set.
 */
JServer.prototype.route = function( req, res, pathname ) {
	var fnmap = this.methods[req.method];
	if ( !fnmap )
		return this.notFound( req, res );
	var fns = fnmap[pathname];
	if ( !fns )
		return this.notFound( req, res );

	var nfns = fns.length;
	if ( nfns == 1 )
		fns[0].apply( this, [req,res] );
	else
		routeCallSeq( this, fns, 0,nfns, req,res );
};

//-------------------------------------------------------------------------
// HTTP request handler
//-------------------------------------------------------------------------

/** 
 * Request body parsers per content type.
 */
JServer.prototype.parsers = {
	'application/json': JSON.parse,
	'application/x-www-form-urlencoded': querystring.parse
};

/**
 * Proxy pass which is called in the beginning of dispatch().
 * Useful e.g. for proxying php requests before any processing is done.
 * Note that extendRequestAndResponse() is called AFTER this, so req.urlc and req.query are not available yet.
 * @return true if request was handled, false if dispatch should continue as normal.
 */
JServer.prototype.proxy = function( req, res ) {
	return false;
};

/**
 * Extend ServerRequest and ServerResponse.
 *
 * @param req ServerRequest
 * ServerRequest is extended by JServer.ServerRequest object members and by parsing url to urlc and query string to query.
 * Function also normalized request method name (HEAD->GET, and OPTIONS->headers['access-control-request-method']).
 * For example, HTTP GET "/hello?name=Jani" gets parsed as:
 * req.urlc = {"search":"?name=Jani","query":"name=Jani","pathname":"/hello","path":"/hello?name=Jani","href":"/hello?name=Jani"};
 * req.query = {"name":"Jani"};
 *
 * @param res ServerResponse
 * ServerResponse extended by JServer.ServerResponse object members and 'req' member is set as the ServerRequest.
 * Also, ServerResponse.head is set to true if no body data should be outputted.
 * 
 * @param body HTTP request body (POST/PUT/etc only)
 * In case body data is defined, ServerRequest body is parsed usings JServer.parsers[content_type] (if any).
 * Raw body is stored in ServerRequest.rawBody and decoded body  is stored in ServerRequest.body.
 * Request body members can be accessed by name, e.g. POST field "a=123" ends up as req.body.a == 123.
 * For example, HTTP POST "/hello" with body "name=Jani" gets parsed as:
 * req.rawBody = "name=Jani";
 * req.body.name = 'Jani';
 */
JServer.prototype.extendRequestAndResponse = function( req, res, body ) {
	var method = req.method;
	if (method === 'OPTIONS')
		req.method = req.headers['access-control-request-method'];
	else if (method === 'HEAD')
		req.method = 'GET', res.head = true;

	if (body) {
		req.rawBody = body;
		var ct = (req.headers['content-type']||'').split(';')[0];
		if ( ct ) {
			var parser = this.parsers[ct];
			req.body = parser?parser(body):body;
		}
	}

	req.urlc = url.parse(req.url);
	req.query = querystring.parse(req.urlc.query);
	res.req = req;
	
	for (var k in this.ServerRequest)
		res[k] = this.ServerRequest[k];
	for (var k in this.ServerResponse)
		res[k] = this.ServerResponse[k];
};

/** 
 * Handles HTTP(S) request. First runs proxy-pass and if that passes, 
 * waits for the request to fully arrive, decodes it, and then forwards it to the route().
 */
JServer.prototype.dispatch = function( req, res ) {
	var self = this;

	if ( this.proxy(req,res) )
		return;
	
	if ( req.method != "GET" && req.method != "HEAD" )
	{
		req.setEncoding( 'utf8' );
		var body = '';
		req.on( 'data', function(chunk) {
			body += chunk;
		} );
		req.on( 'end', function() {
			self.extendRequestAndResponse( req, res, body );
			self.route( req, res, req.urlc.pathname );
		} );
	}
	else {
		this.extendRequestAndResponse( req, res );
		this.route( req, res, req.urlc.pathname );
	}
};

//-------------------------------------------------------------------------
// ServerRequest and ServerResponse prototype objects
//-------------------------------------------------------------------------

/**
 * Prototype object which is used to extend basic ServerRequest before routing.
 * You can extend this prototype with your own functions, and 
 * have those available in any request processed by your application.
 */
JServer.prototype.ServerRequest = {
};

/**
 * Prototype object which is used to extend basic ServerResponse before routing.
 * You can extend this prototype with your own functions, which is handy since 
 * ServerResponse object gets passed around along in requests processing. 
 */
JServer.prototype.ServerResponse = {
	/** 
	 * Access-Control-Allow-Origin header for JSON response. 
	 * Set to '*' to allow requests from any site to this service.
	 * Note: IE8 does not support this. 
	 */
	allowOrigin: '', 

	/** 
	 * HTML UTF-8 response output. Assumes 'this' object is ServerResponse. 
	 * Ends the response.
	 * @param out HTML page to output
	 */
	html: function( out ) {
		this.setHeader( 'Content-Type', 'text/html; charset=utf-8' );
		this.setHeader( 'Content-Length', Buffer.byteLength(out) );
		this.end( this.head?'':out, 'utf8' );
	},

	/** 
	 * JSON UTF-8 object response output. Assumes 'this' object is ServerResponse. 
	 * Ends the response.
	 * @param obj JavaScript value to output
	 */
	json: function( obj ) {
		var out = JSON.stringify(obj);
		this.setHeader( 'Content-Type', 'text/javascript; charset=utf-8' );
		this.setHeader( 'Content-Length', Buffer.byteLength(out) );
		if (this.allowOrigin)
-			this.setHeader( "Access-Control-Allow-Origin", this.allowOrigin );
		this.end( this.head?'':out, 'utf8' );
	}
};

//------------------------------------------------------------------------------------------------
// module.exports
//------------------------------------------------------------------------------------------------

module.exports = JServer;
